Conversation
|
I've assigned @tnull as a reviewer! |
1db2b08 to
49017bd
Compare
|
We've merged the async persistence PR you mentioned. You might want to build your draft PR on the merged commit from there on until we cut you a release. |
Thanks for letting me know. I'll build on the merged commit. |
8f6ba65 to
6499918
Compare
|
Are you stuck? Did something in our library break CI @Camillarhi |
Not stuck at all. I was just closing out some other PRs. Still working on this one, I'll let you know when it's ready. |
12a41ad to
eb97832
Compare
120b089 to
8cc2a31
Compare
1e1efcd to
5fe1a5c
Compare
Implements the receiver side of the BIP 77 Payjoin v2 protocol, allowing LDK Node users to receive payjoin payments via a payjoin directory and OHTTP relay. - Adds a `PayjoinPayment` handler exposing a `receive()` method that returns a BIP 21 URI the sender can use to initiate the payjoin flow. The full receiver state machine is implemented covering all `ReceiveSession` states: polling the directory, validating the sender's proposal, contributing inputs, finalizing the PSBT, and monitoring the mempool. - Session state is persisted via `KVStorePayjoinReceiverPersister` and survives node restarts through event log replay. Sender inputs are tracked by `OutPoint` across polling attempts to prevent replay attacks. The sender's fallback transaction is broadcast on cancellation or failure to ensure the receiver still gets paid. - Adds `PaymentKind::Payjoin` to the payment store, `PayjoinConfig` for configuring the payjoin directory and OHTTP relay via `Builder::set_payjoin_config`, and background tasks for session resumption every 15 seconds and cleanup of terminal sessions after 24 hours.
5fe1a5c to
0411ada
Compare
|
Marking this as ready for review. The core receiver flow is implemented, including session persistence, PSBT handling, input contribution, mempool monitoring for payjoin transactions, and node restart recovery. Two things still pending that I'll follow up with:
Happy to get early feedback on the overall approach in the meantime. |
There was a problem hiding this comment.
hi there!
congrats and thanks for your work!
I talked to @spacebear21 and we agreed that I would review this PR, so I took a first look and here are some points:
needs to run cargo fmt --all
| "InvalidLnurl", | ||
| "PayjoinNotConfigured", | ||
| "PayjoinSessionCreationFailed", | ||
| "PayjoinSessionFailed" |
There was a problem hiding this comment.
PayjoinSessionFailed is used for infrastructure failures (coin selection, InputPair construction) and network errors (poll/post requests). The upstream lib distinguishes these via SelectionError, InputContributionError, and ReceiverError , would it be worth preserving that granularity so callers have more info and know how to act?
There was a problem hiding this comment.
These are the errors exposed at the ldk-node API level. More granular details from the upstream errors are logged for debugging purposes.
|
|
||
| pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> { | ||
| let timeout_fut = tokio::time::timeout( | ||
| Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), |
There was a problem hiding this comment.
since testmempoolaccept is local validation only, shouldn't this use DEFAULT_TX_LOOKUP_TIMEOUT_SECS?
There was a problem hiding this comment.
Yes, will update to use DEFAULT_TX_LOOKUP_TIMEOUT_SECS
| Err(e) => { | ||
| // Check if it's a "not found" error | ||
| let error_str = e.to_string(); | ||
| if error_str.contains("No such mempool or blockchain transaction") |
There was a problem hiding this comment.
matching errors by string seems fragile, as the message varies by server implementation.
e.g. tested: ssl://electrum.blockstream.info:60002: Protocol(String("missing transaction")) , which doesn't match either string here and Ok(None) would never be returned. the caller sees a sync failure instead
| self.runtime.spawn_blocking(move || electrum_client.transaction_get(&txid_copy)); | ||
| let timeout_fut = tokio::time::timeout( | ||
| Duration::from_secs( | ||
| self.sync_config.timeouts_config.lightning_wallet_sync_timeout_secs, |
There was a problem hiding this comment.
this is a single onchain tx lookup, should this use tx_broadcast_timeout_secs (10s) instead of lightning_wallet_sync_timeout_secs (30s)? or would a dedicated tx_lookup_timeout_secs field make more sense?
| &self, proposal: Receiver<Monitor>, persister: &KVStorePayjoinReceiverPersister, | ||
| ) -> Result<(), Error> { | ||
| // On a session resumption, the receiver will resume again in this state. | ||
| let poll_interval = tokio::time::Duration::from_millis(200); |
There was a problem hiding this comment.
this seems aggressive for a daemon (payjoin-cli uses 200ms because its a one-shot CLI with manual retry) but for ldk node maybe 1-5 seconds would be more appropriate?
| }) | ||
| .save_async(persister) | ||
| .await | ||
| .map_err(|_| Error::PersistenceFailed)?; |
There was a problem hiding this comment.
finalize_proposal returns MaybeTransientTransition to distinguish transient from fatal errors, but .map_err(|_| Error::PersistenceFailed) collapses both into the same error. Transient failures won't be retried and will surface as PersistenceFailed, which is misleading
There was a problem hiding this comment.
PersistenceFailed here doesn't cancel the session. The session remains active in its current state and will be retried on the next poll cycle. Sessions are only marked as failed when we receive a Closed event. Retrying transient failures is handled naturally by the polling loop.
| (4, secret, option), | ||
| } | ||
| }, | ||
| (11, Payjoin)=>{ |
There was a problem hiding this comment.
all other PaymentKind variants use even TLV tags (0, 2, 4, 6, 8, 10), was 11 intentional, or should this be 12?
There was a problem hiding this comment.
11 is intentional here. Since this is newly added, we're following the "it's okay to be odd" rule, which allows odd TLV types for optional fields that can be safely ignored by older readers. So 11 is correct rather than 12.
| Arc::clone(&kv_store), | ||
| Arc::clone(&logger), | ||
| )), | ||
| Err(e) => { |
There was a problem hiding this comment.
payjoin_session_store_res doesn't handle ErrorKind::NotFound, which will occur on any node that has never used payjoin. fix suggestion in read_payjoin_sessions
There was a problem hiding this comment.
Thanks for the suggestion, but the list() implementations across all return Ok(vec![]) when the namespace is empty or hasn't been written to yet.
| #bitcoin-payment-instructions = { version = "0.6" } | ||
| bitcoin-payment-instructions = { git = "https://github.com/jkczyz/bitcoin-payment-instructions", rev = "869fd348c3ca0c78f439d2f31181f4d798c6b20e" } | ||
|
|
||
| payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", package = "payjoin", default-features = false, features = ["v2", "io"] } |
| PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE, | ||
| PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE, | ||
| ) | ||
| .await?; |
There was a problem hiding this comment.
list() returns NotFound when the namespace has never been written, maybe consider something like this handling it?
rust.or_else(|e| if e.kind() == lightning::io::ErrorKind::NotFound {
Ok(vec![])
} else {
Err(e)
})?;
There was a problem hiding this comment.
Thanks for the suggestion, but the list() implementations across all return Ok(vec![]) when the namespace is empty or hasn't been written to yet.
|
🔔 1st Reminder Hey @tnull! This PR has been waiting for your review. |
Thanks for the review. |
|
🔔 2nd Reminder Hey @tnull! This PR has been waiting for your review. |
| SessionOutcome::Failure => { | ||
| log_error!(self.logger, "Payjoin session failed due to a protocol error."); | ||
|
|
||
| let fallback_tx = session.fallback_tx.as_ref().ok_or(Error::InvalidPaymentId)?; |
There was a problem hiding this comment.
test_session_fatal_error covers Failure with fallback_tx: None, when check_broadcast_suitability rejects the PSBT. In that case, the ? returns before failed status is persisted
I'm using as default nightly toolchain and ldk node uses stable, so that's what was happening |
spacebear21
left a comment
There was a problem hiding this comment.
This is looking great! I have high-level feedback from the payjoin side, but overall agree with the approach and implementation.
| ChainSourceKind::Esplora{..} => { | ||
| // Esplora does not support a testmempoolaccept equivalent, so we skip | ||
| // the broadcast suitability check and assume the transaction is broadcastable. | ||
| log_debug!(self.logger, "Skipping broadcast suitability check: not supported by Esplora backend."); | ||
| Ok(true) | ||
| }, | ||
| ChainSourceKind::Electrum{..} => { | ||
| // Electrum does not support a testmempoolaccept equivalent, so we skip | ||
| // the broadcast suitability check and assume the transaction is broadcastable. | ||
| log_debug!(self.logger, "Skipping broadcast suitability check: not supported by Electrum backend."); | ||
| Ok(true) |
There was a problem hiding this comment.
The broadcast suitability check is important for non-interactive receivers (e.g. payment processors) to protect against probing attacks, where a malicious sender can repeatedly send proposals to have the non-interactive receiver reveal the UTXOs it owns with the proposals it modifies. The "broadcastable" check ensures the receiver can broadcast the fallback transaction in case the payjoin fails, effectively incurring a cost to the attacker.
Electrum's lack of support for this kind of check is a longstanding issue unfortunately.
I'm not familiar enough with LDK-node usage to know if it's typically used in an interactive way (receiver takes manual action to receive payment) or non-interactive (e.g. bolt12 or similar static payment with no receiver action per payment)?
In interactive conditions, the receiver may use the assume_interactive_receiver transition method to explicitly skip the broadcast check altogether. In non-interactive conditions though, we should find an alternative way to perform this check. Or at least return an error instead of silently skipping the check, so it can be handled by the implementer.
| // is assumed to be broadcastable. | ||
| let proposal = proposal | ||
| .check_broadcast_suitability(None, |tx| { | ||
| self.chain_source |
There was a problem hiding this comment.
So here for example we could call proposal.assume_interactive_receiver() if applicable.
| Error::PayjoinSessionFailed | ||
| })?; | ||
| let proposal = proposal | ||
| .contribute_inputs(vec![selected_input]) | ||
| .map_err(|e| { | ||
| log_error!(self.logger, "Failed to contribute inputs to payjoin: {}", e); | ||
| Error::PayjoinSessionFailed |
There was a problem hiding this comment.
Are these failure modes handled by broadcasting the fallback transaction? I'm trying to find where these errors are handled but not sure I'm tracing it correctly.
| /// The URL of the OHTTP relay to use for sending OHTTP requests to PayJoin receivers. | ||
| pub ohttp_relay: URL, |
There was a problem hiding this comment.
We can make this a list of OHTTP relays and randomize the relay per-request. This also improves reliability by enabling retries to other relays in case one is offline for any reason.
| .payjoin_session_store | ||
| .list_filter(|s| { | ||
| let is_terminal = | ||
| s.status == PayjoinStatus::Completed || s.status == PayjoinStatus::Failed; |
There was a problem hiding this comment.
It may be desirable to keep "Completed" sessions ~forever since it makes it possible to reconstruct past Payjoins for e.g. displaying transaction history.
This PR adds support for receiving payjoin payments in LDK Node. This is currently a work in progress and implements the receiver side of the payjoin protocol.
KVStorePayjoinReceiverPersisterto handle session persistencePayjoinas aPaymentKindto the payment storeNote on persistence: The payjoin library currently only supports synchronous persistence, but they're working on adding async support(payjoin/rust-payjoin#1235). This PR sets up the persistence structure (
KVStorePayjoinReceiverPersister), which will be updated to use async operations once the upstream PR is merged.This PR will partially address #177